Skip to content

Setting up a React project

A React app needs a small amount of tooling around it before any of the code runs. Something has to turn JSX into JavaScript a browser can execute, and something has to serve the result while you work on it. Vite does both, and it scaffolds a working project in about thirty seconds. This chapter goes from an empty folder to a running app, then traces the one path that matters most: how a component ends up on the screen.

Creating a project

Vite is a build tool with a project generator built in. Run it with npm create, answer two prompts, and you have a project:

bash
npm create vite@latest my-react-app
cd my-react-app
npm install
npm run dev

The first command asks which framework you want (choose React) and which variant (choose JavaScript, or TypeScript if you're using it). It writes a folder called my-react-app with everything a React project needs. npm install downloads the dependencies listed in package.json, which is where React itself comes from. npm run dev starts the development server and prints a local address, usually http://localhost:5173. Open that in a browser and the starter app is running.

The generated folder has a handful of files that matter early on:

  • index.html: the single HTML page the browser loads.
  • src/main.jsx: the entry point, where React attaches to that page.
  • src/App.jsx: your top-level component, and the file you'll edit first.
  • src/assets/: images and other static files you import into components.
  • package.json: dependencies and the dev, build, and preview scripts.
  • vite.config.js: build configuration, which you can leave alone for a long time.

How the pieces connect

Three files hand off to each other in a straight line. Start at index.html, which is deliberately almost empty:

html
<body>
  <div id="root"></div>
  <!-- followed by a <script type="module" src="/src/main.jsx"> tag -->
</body>

That empty <div id="root"> is the spot on the page React is allowed to fill. Everything your app renders lands inside it. The module <script> tag below it loads main.jsx, which is where React takes over:

jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>
)

Read that call right to left. document.getElementById('root') finds the empty div. createRoot wraps it in a React root, meaning React now owns the contents of that element and will keep them in sync with your components. .render(...) tells it what to put there: the App component.

<StrictMode> is a development-only wrapper that runs extra checks on everything inside it and warns about patterns likely to cause bugs. It adds nothing to the page and switches itself off in a production build. Its most visible effect turns up in Effects, where it deliberately mounts each component twice during development.

App.jsx is an ordinary component that exports itself:

jsx
export default function App() {
  return <h1>Hello from React</h1>
}

That is the whole chain. The browser loads index.html, the script loads main.jsx, main.jsx calls createRoot on the root div and renders <App />, and App returns the JSX that becomes the heading you see. Every component you write from here on sits somewhere inside App, so it reaches the page through this same path. main.jsx is usually written once and rarely touched again; almost all of your work happens in App.jsx and the components it pulls in.

Version note

React 17 and earlier used ReactDOM.render(<App />, document.getElementById('root')) as the entry point. React 18 introduced createRoot, which is what turns on concurrent rendering, and left the old call working with a deprecation warning. React 19 removed it, so ReactDOM.render now throws an error. On a tutorial that still uses it, createRoot is the line to write in its place. See History and versions for what changed.

The dev server

npm run dev starts Vite's development server, and it stays running in the terminal while you work. Its job is to serve your app and to react to your edits.

Save a change to App.jsx and the browser updates almost immediately, without a manual refresh. That's hot module replacement: Vite pushes the changed module to the page and swaps it in place, leaving the rest of the app running. A counter you'd clicked up to seven usually still reads seven after the edit, so you keep whatever state you had set up while you tweak the markup around it. Some changes still force a full reload, and Vite decides that for you.

Two other scripts come with the project. npm run build produces an optimized bundle in a dist folder, which is what you deploy. npm run preview serves that built output locally so you can check it before shipping. During development, npm run dev is the only one you need.

Importing static assets

Images live under src/assets and come into a component through an import:

jsx
import logo from './assets/logo.png'

export default function Header() {
  return <img src={logo} alt="Company logo" />
}

The import gives you a variable holding the final URL of that image, which you then pass to src in curly braces. Writing src="./assets/logo.png" as a plain string tends to break, because the paths in your source folder are rarely the paths in the built output.

Importing the file instead puts the build tool in the loop. It sees that your code depends on logo.png, copies it into the output, and gives you back whatever URL that file ends up at. Move your component into a different folder and the relative import still resolves. Misspell the filename and you find out immediately, because the import fails, rather than discovering a broken image in production. The same pattern works for SVGs, fonts, and any other file the build tool knows how to handle.

A browser has no idea what a .jsx file is. There's no JSX parser in any JavaScript engine, and <App /> is a syntax error in plain JavaScript. What the browser receives has already been transformed. Vite runs every .jsx file through esbuild, which rewrites the JSX into function calls: under React 19's automatic runtime, <App /> becomes a call along the lines of _jsx(App, {}). Which helper you get depends on the mode. The production transform imports jsx from react/jsx-runtime, while the development transform imports jsxDEV from react/jsx-dev-runtime and passes extra arguments carrying the source file and line number, which is how a React warning in your console can point at the exact spot in your code. The .jsx extension is mostly a signal to tooling that this file contains syntax needing that transform. By the time anything reaches the page, it's ordinary JavaScript modules calling ordinary functions.

The dev server and the production build take different routes to that result. In development, Vite serves native ES modules and transforms each file on request, so startup time barely grows with project size and a single edit only reinvalidates that one module. npm run build switches to Rollup, which bundles the whole graph, tree-shakes unused exports, minifies, and splits code into chunks. Behavior can differ slightly between the two, which is why npm run preview exists.

The image import works because the bundler treats non-JavaScript files as part of the module graph too. import logo from './assets/logo.png' is not a real JavaScript module import. Vite intercepts it, emits the file into dist/assets with a content hash in the name, and replaces the import with a string literal of that final path, something like /assets/logo-4f2a1c8b.png. The hash is what makes aggressive caching safe: change the image and the filename changes with it, so no stale copy survives in a CDN. Files below a size threshold (4KB by default) skip the separate request entirely and get inlined as a base64 data URL. In development the same import resolves to a plain path served by the dev server, which is why the URL you see in devtools differs between npm run dev and a real build.

JunoOne path from the page to your component Setting up is four commands, and then you can forget about it. What's worth holding onto is the chain: index.html has an empty div with the id root, main.jsx grabs that div with createRoot and renders <App /> into it, and App.jsx is the component that says what appears. Once you can follow that path, you know exactly where your own code plugs in.
JunoOne path from the page to your componentnpm create vite@latest, pick React, install, npm run dev. From there it's index.html to main.jsx to App.jsx: the entry file calls createRoot on the root div once and renders your top component, and you spend the rest of your time below that line. Import images rather than hardcoding paths so the build tool resolves and fingerprints them for you.
JunoOne path from the page to your component The setup exists because JSX has no runtime: esbuild rewrites <App /> into runtime calls, jsx from react/jsx-runtime in a build and jsxDEV from react/jsx-dev-runtime in development, before anything reaches the browser, and asset imports resolve to hashed URLs the bundler emits. Dev runs unbundled ES modules through esbuild, production runs Rollup, so treat npm run preview as the check that the two agree. createRoot is the React 18 entry point, and it is what opts your tree into concurrent rendering.

Next up: Components, the building blocks you'll fill that App.jsx with.