Home / Articles / A Production-Ready React Baseline: What Every Package Actually Does

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.

3165 words

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/toolkit is the store itself: it holds application data and applies updates to it.
  • react-redux is 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:

  • useState for 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.tsx files, 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 against null by 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 : string and 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)
: 'babel-jest', }, transformIgnorePatterns: ['node_modules/(?!(react-router|cookie-es)/)'], moduleNameMapper: { '\\.(css|less|scss|sass)
: '<rootDir>/test/styleMock.js', '\\.(png|jpg|jpeg|gif|svg|webp)
: '<rootDir>/test/fileMock.js', }, }

The transformIgnorePatterns entry deserves a note. By default Jest does not transform anything inside node_modules. The negative lookahead makes an exception for react-router and cookie-es, which are published as ES modules that Jest's CommonJS pipeline cannot load untransformed. If you later add another ESM-only dependency and see a SyntaxError: Cannot use import statement outside a module, this pattern is where it belongs.

jest.setup.ts

The setup file registers the jest-dom matchers and adds TextEncoder and TextDecoder to the global scope. The jsdom environment does not expose them, while React Router expects them to exist, so they are borrowed from Node's node:util module:

import { TextEncoder, TextDecoder } from 'node:util'
import '@testing-library/jest-dom'

Object.assign(globalThis, { TextEncoder, TextDecoder })

Style and file mocks

Jest has no idea how to import a CSS file or a PNG. The two stub modules below are where moduleNameMapper redirects those imports, so a component containing import './App.css' does not crash the test run:

// test/styleMock.js
module.exports = {}

// test/fileMock.js
module.exports = 'test-file-stub'

src/App.test.tsx

Finally, the test that exercises the whole configuration. It renders App inside a MemoryRouter, which keeps routing state in memory instead of reading the real browser URL, and asserts that the placeholder text is present:

import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import App from './App'

test('renders the placeholder route content', () => {
  render(
    <MemoryRouter>
      <App />
    </MemoryRouter>,
  )
  expect(screen.getByText(/routes coming soon/i)).toBeInTheDocument()
})

Small as it is, this test touches every piece of plumbing: TSX compilation, the ESM router package, the text-encoding polyfills, import.meta handling and the jest-dom matcher. If it passes, the setup is sound.

Prettier: ending formatting debates

Packages: prettier, eslint-config-prettier

npm install -D prettier eslint-config-prettier
  • prettier reformats your code into a single consistent style, typically on save.
  • eslint-config-prettier has one narrow purpose: it turns off ESLint rules that conflict with Prettier's formatting, such as rules about quotes, semicolons and trailing commas.

The second package adds no rules of its own; it only prevents the two tools from fighting. In eslint.config.js it must be the last entry in your configuration list, because later entries override earlier ones, and it needs to override the others rather than be overridden by them.

Why it is worth the extra package

Without automated formatting, review time drifts toward tabs versus spaces instead of logic. Prettier deliberately offers only a handful of options, so there is little left to argue about, and that is precisely the point.

Wiring it all together

One gap remains. tsc -b type-checks everything under src, test files included, and by default it knows nothing about test or expect. Add the relevant type packages to the types array in tsconfig.app.json:

"types": ["vite/client", "jest", "@testing-library/jest-dom"]

Then run the full verification sequence, cheapest step first:

npm run lint    # fast, catches obvious mistakes
npm test        # fast, catches broken behaviour
npm run build   # slower — real compile, real Tailwind output
npm run dev     # slowest — but the only one that proves it renders

Linting is fast and catches obvious mistakes, tests confirm behavior, the build performs a real compile and generates the actual Tailwind output, and the dev server, the slowest check, is the only one that proves the app renders in a browser. When all four pass, the foundation is done, without a single line of feature code.

A note on alternatives

Much of the Jest section (the Babel presets, the import.meta plugin, the ESM exceptions) exists because Jest does not share Vite's build pipeline. If you prefer fewer moving parts, Vitest reuses your Vite config and works with the same Testing Library packages. For a different angle on reducing test tooling, see our guide to replacing Jest with Node's native test runner. The Jest setup above remains a solid choice when your team already knows Jest or relies on its ecosystem.

Key takeaways

  • Treat each dependency as a decision: know what it does and what fails without it.
  • Tailwind v4 needs only the Vite plugin and one CSS import; no config file is required.
  • Redux Toolkit holds the data and React Redux connects it to components; typed hooks keep usage clean, and local UI state still belongs in useState.
  • Adding the router early makes every future page a one-line change.
  • Jest in a Vite project needs a runner, a DOM environment, three Testing Library packages, a Babel toolchain and a few targeted config fixes; when a fix seems ignored, clear the Jest cache.
  • Install interdependent packages in a single command, and use explicit line continuations when a command spans lines.